TDD(Test-Driven Development,測試驅動開發)是一種以「先寫測試、後寫程式」為核心的開發方法。傳統開發往往先撰寫功能,再事後補上測試;而在 TDD 中,開發流程被刻意反轉。
先想清楚「期望程式行為」並以測試程式的方式描述它,然後再讓開發程式碼去通過這個測試。這個循環通常簡稱為 Red–Green–Refactor:
階段 | 動作 | 結果 |
---|---|---|
紅燈 | 寫完測試、執行 pytest | 測試失敗,確認測試邏輯正確 |
綠燈 | 新增最小程式碼 | 所有測試通過 |
藍燈 | 重構程式、移除重複 | 維持通過狀態,程式更乾淨 |
TDD 的價值不僅在於提升測試覆蓋率,更重要的是它能引導開發者以「需求」為中心思考設計。因為測試事先定義了介面與期望結果,開發者在實作階段能明確知道「什麼是足夠的功能」,也能更容易拆分問題、重構程式,減少未來維護的難度。
以資料庫功能為例,TDD 會先定義出資料的 CRUD 行為測試(例如「新增使用者後能成功查詢出來」),再逐步實現每個資料操作。這種流程讓開發者能及早發現邏輯錯誤、結構不合理或資料設計問題。
類型 | 典型順序 | 說明 |
---|---|---|
一般開發流程 | 實作功能 → 撰寫單元測試 → 驗證功能 | 先把功能寫出來,再補測試 |
TDD(測試驅動開發)流程 | 撰寫單元測試 → 實作功能 → 重構 | 先寫測試,再寫讓測試通過的最小實作 |
在 TDD 模式下,每個設計決策都須具備「可測試性」。
以下為本專案設計的三大原則:
簡潔性(KISS)
nutrients_json
。可測試性(Testability)
sqlite:///:memory:
,確保每個測試函式皆獨立、快速且不干擾其他案例。最小可行實作(MVP)
目標模型:AnalysisRecord
此模型對應一筆 AI 食物分析結果,包含原始資料、摘要、營養成分等欄位。
欄位名稱 | 型別 | 說明 |
---|---|---|
id |
Integer, Primary Key | 唯一識別碼 |
image_path |
String(255), Unique, Not Null | 圖片路徑(避免重複上傳) |
created_at |
DateTime (timezone-aware) | 建立時間 |
raw_analysis |
Text, Not Null | AI 回傳的原始文字或 JSON |
summary |
Text | 食物摘要描述 |
nutrients_json |
Text | 儲存營養細項(JSON 字串) |
此外,會提供對應的 nutrients
屬性,
讓開發者可直接以 Python 結構(list/dict)操作,而非手動處理 JSON 字串。
tests/conftest.py
與 tests/test_models.py
。models.py
內的最小邏輯(nutrients
property、timezone-aware 時間欄位)。Model.query.get
→ db.session.get
)。測試案例需驗證以下行為:
測試項目 | 驗證內容 |
---|---|
建立紀錄 | 可建立一筆 AnalysisRecord ,並寫入 nutrients (Python 結構) |
JSON 正確儲存 | 確認 nutrients_json 內為合法 JSON 字串 |
查詢行為 | 能正確讀回同筆紀錄與對應欄位 |
更新行為 | 更新 summary 後可正確讀回新值 |
刪除行為 | 可刪除該筆紀錄並驗證資料消失 |
進階測試 | created_at 欄位自動生成且為 timezone-aware |
tests/conftest.py
)此檔案負責建立 Flask 測試應用與測試資料庫。
import pytest
from flask import Flask
try:
from app import create_app as _create_app
except Exception:
_create_app = None
try:
from models import db as _db
except Exception:
_db = None
@pytest.fixture(scope="function")
def app():
"""建立測試用 Flask App,使用 in-memory SQLite"""
config = {
"TESTING": True,
# 使用 `sqlite:///:memory:` 讓測試資料存在記憶體中。
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
}
if _create_app is not None:
app = _create_app(config)
else:
app = Flask(__name__)
app.config.update(config)
if _db is None:
raise RuntimeError("找不到 models.db,請確認 models.py 有定義 db = SQLAlchemy()")
_db.init_app(app)
with app.app_context():
_db.create_all()
yield app
_db.session.remove()
_db.drop_all()
@pytest.fixture
def db(app):
"""提供資料庫 session 給測試案例"""
return _db
tests/test_models.py
)import json
from models import AnalysisRecord, db
def test_analysisrecord_crud_and_nutrients_json(db):
"""測試 CRUD 與 JSON 欄位行為"""
rec = AnalysisRecord(
image_path="uploads/test_apple_pie.jpg",
raw_analysis="{\"ai\":\"result\"}",
summary="蘋果派 測試"
)
rec.nutrients = [
{"nutrient": "calories", "value": 250},
{"nutrient": "fat", "value": 12.5}
]
db.session.add(rec)
db.session.commit()
# 查詢
found = AnalysisRecord.query.filter_by(image_path="uploads/test_apple_pie.jpg").first()
assert found is not None
assert found.summary == "蘋果派 測試"
assert isinstance(found.nutrients, list)
assert found.nutrients[0]["nutrient"] == "calories"
# 驗證 JSON 結構
parsed = json.loads(found.nutrients_json)
assert parsed[1]["value"] == 12.5
# 更新
found.summary = "更新摘要"
db.session.commit()
updated = db.session.get(AnalysisRecord, found.id)
assert updated.summary == "更新摘要"
# 刪除
db.session.delete(updated)
db.session.commit()
assert db.session.get(AnalysisRecord, found.id) is None